Skip to content

Create tombstone module#736

Open
Fabrizzio53 wants to merge 11 commits into
Pennyw0rth:mainfrom
Fabrizzio53:main
Open

Create tombstone module#736
Fabrizzio53 wants to merge 11 commits into
Pennyw0rth:mainfrom
Fabrizzio53:main

Conversation

@Fabrizzio53
Copy link
Copy Markdown
Contributor

@Fabrizzio53 Fabrizzio53 commented Jun 13, 2025

Description

This new module allows users to list, restore objects from "Deleted Objects" container, and delete active objects.

  • Listing deleted objects:
    The module queries deleted objects using the LDAP filter (isDeleted=TRUE) along with the control LDAP_SERVER_SHOW_DELETED_OID, which is required to retrieve tombstoned entries. The query targets the DN CN=Deleted Objects,DC=.... By default, the first entry is skipped as it represents the container itself, not a real AD object (e.g., user or computer).

  • Restoring objects:
    To restore a deleted object, the module uses the ID obtained during the query and:

    • Removes the isDeleted attribute.
    • Replaces the distinguishedName with the value of lastKnownParent.

    This only works if the AD Recycle Bin is enabled and the user has the necessary permissions.

  • Deleting objects:
    The module also supports deleting objects to return to their "tombstoned form" using the .delete() method from ldap3. This is useful in scenarios where multiple objects with similar names exist (e.g., multiple versions of a user), and selective restoration or cleanup is needed.

Type of change

  • New feature (non-breaking change which adds functionality)

How Has This Been Tested?

Tested on a local lab that I created and HTB, tried to test at my work but I don't have the privs to do that ;)

Screenshots (if appropriate):

image

After removing the user from this OU the query option (default) can be used to recover all the necessary informations from the Deleted Objects container.

image

Right now the user has no way to confirm if he has or not privileges (this is something that in a future update a DACL parser can be added but I don't know how to do that now).

To restore an object from the Deleted Objects the user needs to call the "restore" action and pass the ID value recovered from the query action.

image

The first call to ldap fail since the user is not active, after the restore action the ldap connection worked again.

To delete the object the DN must be passed to the deleted action.

image

Checklist:

  • I have ran Ruff against my changes (via poetry: poetry run python -m ruff check . --preview, use --fix to automatically fix what it can)
  • If reliant on changes of third party dependencies, such as Impacket, dploot, lsassy, etc, I have linked the relevant PRs in those projects
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas

References

To perform this attack some actions must be performed at the domain:

https://nettools.net/how-to-delegate-object-restoration-rights/
https://learn.microsoft.com/pt-br/troubleshoot/windows-server/active-directory/non-administrators-view-deleted-object-container

By default only DA can restore and list objects from the Deleted Objects, I created this module after doing some machines from htb and vulnlab that AD Recycle Bin was active and you need to do a winrm / rdp session to execute the Restore-ADObject cmdlet, with this module you don't need to have a valid windows session anymore.

Comment thread nxc/modules/gravedigger.py Outdated
Comment thread nxc/modules/gravedigger.py Outdated
@RajChowdhury240
Copy link
Copy Markdown

tombstone module name would be ideal i guess

@Fabrizzio53
Copy link
Copy Markdown
Contributor Author

I can change the name with no problem if everyone agrees

@NeffIsBack
Copy link
Copy Markdown
Member

Probably a less terrifying module name😅

@Marshall-Hallenbeck
Copy link
Copy Markdown
Collaborator

I like the name :P

@Fabrizzio53 Fabrizzio53 changed the title Create gravedigger.py Create tombstone.py Jul 7, 2025
@Fabrizzio53
Copy link
Copy Markdown
Contributor Author

Name changed

@RajChowdhury240
Copy link
Copy Markdown

RajChowdhury240 commented Jul 13, 2025

First of all amazing work mate! one thing i noticed

image image

it doesnt work with where machine only allows kerberos auth (NO NTLM) i used same syntax with -k option & it breaks , can you add kerberos support too ?

@Fabrizzio53
Copy link
Copy Markdown
Contributor Author

Hi, yes this is a known issue by me, but the problem is at impacket itself, I pushed a pr to impacket that fix something that was made when calling get_machine_name:

fortra/impacket#1997

but netexec uses another impacket, https://github.com/Pennyw0rth/impacket, when the pr that I made from impacket gets merged to the impacket that netexec use, then I can make the changes and the script will work again even when ntlm is not suported.

(I think I will try to make the changes here already but it will not work while impacket does not get merged)

@Fabrizzio53
Copy link
Copy Markdown
Contributor Author

Fabrizzio53 commented Jul 13, 2025

Done, but remember at the time that I'm writting this, impacket from netexec don't have the updated version, I know what machine you are doing and I used as a test example, if you want to test before the merge happens you need to modify this file:

/impacket/examples (line 241)

image

This is the same pr that I did for impacket to fix this problem when ntlm is disabled

@Marshall-Hallenbeck
Copy link
Copy Markdown
Collaborator

New modules need to be added to the e2e tests

@Fabrizzio53
Copy link
Copy Markdown
Contributor Author

Just to be sure I need to edit e2e_commands.txt right? But I think my fork has a different e2e because it is saying "This branch has conflicts that must be resolved"

@Marshall-Hallenbeck
Copy link
Copy Markdown
Collaborator

Just to be sure I need to edit e2e_commands.txt right? But I think my fork has a different e2e because it is saying "This branch has conflicts that must be resolved"

Then resolve them?

@Fabrizzio53
Copy link
Copy Markdown
Contributor Author

First time doing that, learned something new

@NeffIsBack
Copy link
Copy Markdown
Member

but netexec uses another impacket, https://github.com/Pennyw0rth/impacket, when the pr that I made from impacket gets merged to the impacket that netexec use, then I can make the changes and the script will work again even when ntlm is not suported.

Gonna update our impacket fork, give me a bit

@NeffIsBack
Copy link
Copy Markdown
Member

Done, our impacket should be up-to-date now

@Fabrizzio53
Copy link
Copy Markdown
Contributor Author

Tested here again, working against domains where ntlm is disabled

Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
…ript

Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
When netexec impacket gets merged with the most up to date version from fortra this fix will work when a domain disabled ntlm

Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
Removed the LDAP3 connection and now use impacket with the new CRUD method from the latest merged PR, removed the SSL option since now the first connection is handled by netexec and not ldap3

Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
@Fabrizzio53
Copy link
Copy Markdown
Contributor Author

Fabrizzio53 commented Apr 15, 2026

I decided to remove the ldap3 dependency and use the LDAP CRUD implementation built on impacket. Decided to rewrite some parts of the code too and removed the SSL options since now netexec handles a valid connection. From my domain the code is working, but if anyone finds a bug please let me know

fixed ruff checks

Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
Copy link
Copy Markdown
Member

@NeffIsBack NeffIsBack left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for removing ldap3.

A few things left to do, but overall the code looks already quite good :)

Comment thread nxc/modules/tombstone.py Outdated
Comment thread nxc/modules/tombstone.py Outdated
Comment thread nxc/modules/tombstone.py Outdated
Comment thread nxc/modules/tombstone.py Outdated
Comment thread nxc/modules/tombstone.py Outdated
Comment thread nxc/modules/tombstone.py Outdated
Comment thread nxc/modules/tombstone.py Outdated
Comment thread nxc/modules/tombstone.py Outdated
Comment thread nxc/modules/tombstone.py Outdated
Comment thread nxc/modules/tombstone.py Outdated
removed imports that were not being used, remove None returns, put the options at the start of the script and improved on indentation

Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
@MTlyx
Copy link
Copy Markdown

MTlyx commented Apr 30, 2026

There is a typo in the usage example for nxc ldap -M tombstone --options : the trailing " in nxc ldap $DC-IP -u Username -p Password -M tombstone" should be removed.

Removed a typo " in the first example

Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
@Fabrizzio53
Copy link
Copy Markdown
Contributor Author

Removed the typo

@remiotore
Copy link
Copy Markdown

Hi! Are there news about this? Im currently using this module and its pretty valuable.

@NeffIsBack
Copy link
Copy Markdown
Member

Hi :)

Unfortunately my time is really really short atm so I hadn't have the time yet to rereview it, but this should get better in Q3. My priority at the moment is to get #1191 merged since it eliminates a lot of issues with RPC and we should then have much more stable RPC interactions. However, since this PR waited quite a while now this is definitely high up on my priority list and I will review it soon afterwards.

Feel free to ping me from time to time if I don't start working on it.

Comment thread nxc/modules/tombstone.py Outdated
if "container" in entries["objectClass"] and entries["description"] == "Default container for deleted objects":
continue

context.log.highlight(f"{'sAMAccountName':<20}: {entries['sAMAccountName']}")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not all tombstoned objects have sAMAccountName (OUs, contacts, etc.), so this raises KeyError and crashes the module, see the stack trace on query. Use .get() with a default (e.g. entries.get('sAMAccountName', '')) like that (ldap.py) :

 self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<9}{'-Description-':<60}")
            for user in resp_parsed:
                pwd_last_set = user.get("pwdLastSet", "")
                if pwd_last_set:
                    pwd_last_set = "<never>" if pwd_last_set == "0" else datetime.fromtimestamp(self.getUnixTime(int(pwd_last_set))).strftime("%Y-%m-%d %H:%M:%S")
                # We default attributes to blank strings if they don't exist in the dict
                self.logger.highlight(f"{user.get('sAMAccountName', ''):<30}{pwd_last_set:<20}{user.get('badPwdCount', ''):<9}{user.get('description', ''):<60}")
                users.append(user.get("sAMAccountName", ""))
Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code now uses context.log.highlight(f"{'sAMAccountName':<20}: {entries.get('sAMAccountName', '')}"), created an OU, removed and tested here, from the objects that I found, dn, ID, isDeleted and lastknowparent should be an attribute that is present on all types, but I could be wrong, in any case, this failsafe using .get() is being used at all attributes

Comment thread nxc/modules/tombstone.py
self.action = "query"
self.id = ""
self.deleteDN = ""
if "ACTION" in module_options:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACTION is not validated, a typo (e.g., ACTION=restor) will not match any branch in on_login, and the module will exit silently. Please validate it in options() using .lower() and a whitelist (query, restore, delete), and use elif/else with log.fail in on_login for unknown values. You can see link_enable_cmdshell.py the pattern to follow

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored the code to use .lower() and show an error message when an attribute that is not query, restore or delete gets passed

Comment thread nxc/modules/tombstone.py Outdated
context.log.highlight(f"{'lastKnownParent':<20}: {entries['lastKnownParent']}")
context.log.highlight("")

self.__sAMAccountName = entries["sAMAccountName"]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same .get() issue here, see above

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied the same fix from the other issue, the code now uses the .get() when grabbing the attributes

Comment thread nxc/modules/tombstone.py Outdated
Comment thread nxc/modules/tombstone.py Outdated
Comment thread nxc/modules/tombstone.py Outdated
Comment thread nxc/modules/tombstone.py Outdated
Comment thread nxc/modules/tombstone.py Outdated
context.log.highlight("No objects are in a tombstone state")
return False

context.log.highlight(f"Found {len(resp) - 1} deleted objects")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think len(resp) - 1 doesn't match the number of objects actually displayed (the default container for deleted objects is filtered out in the loop). We should count the entries after filtering, or increment the value within the loop

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are right, created a temporary var and increment for each value of deleted object that it founds, the number of objects is now shown at the end of the module

refactored the code to return empty sAMAccountName when object does not have this attribute.

removed some variables that were not being used, such as opsec, multiple_host and self.__domain.

removed internal domain to dn function to now use connection.baseDN.

the number of deleted objects should be right now, temporary var is created and incremented for each deleted object found.

Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com>
@Fabrizzio53
Copy link
Copy Markdown
Contributor Author

Fabrizzio53 commented May 26, 2026

After @azoxlpf comment about the sAMAccountName not being present on all objects, I decided to change the logic at restore_deleted_objects, from my tests it is working, in the .modify() the samAccountName was being used to create the original DN of the user before deletion, now the originalDN is getting built from the DN and lastKnownParent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants